本篇會提及的 Mock 在 Unit Test 中扮演著很重要的角色,因為單元測試必須將關注點放在要被測試的 function 身上,不能讓不確定性在 function 外的地方發生,因此要使用 Mock 迴避掉不確定性,但是針對不同的狀況也會需要不同的 Mock,就讓本文來解析吧!
在提到 Mock 前,我想先解釋一下何謂依賴,這時請大家先從 npm 下載 uuid 這個套件:
npm install uuid --save
接著在 src/utils 中創建一個 car.js,並新增以下程式碼,用途為將選取的商品放置於購物車內:
import uuid from 'uuid/v1';
const car = {
carContent: [],
getCurrentCar: () => car.carContent,
addProdToCar: (name, count) => {
const workCar = [...car.getCurrentCar()];
workCar.push({ id: uuid(), name, count });
return workCar;
},
};
export default car;
上方程式中的 car
職責為管理購物車的操作(目前只有增加的功能),程式將購物車內容用 carContent
紀錄,我們得用 getCurrentCar
來取得它,然後雖然有點雞肋,但在購物車內增加商品項目的時候,還另外用了 uuid
框架,替每筆資料標記一個唯一值,最後再將整個 car
作為一個 model export 出去。
因此在 addProdToCar
的短短兩行程式碼中,就使用了 getCurrentCar
這個 function,以及框架 uuid
,除了更改程式碼以外,少了兩個之中的任何一個都會出錯,這個狀況,就可以說明了 addProdToCar
依賴著 getCurrentCar
和 uuid
。
這是一個很好的 function,並不是寫法方面,而是他能夠同時讓我們練習到幾種 Mock 的方法,接下來會一一介紹。
Mock 替身函式,也被稱為 Test Double ,作用就如同字面上的意思,當各位在測試某個 function 的時候,多少會遇到需要依賴其他 module 或 function 的狀況,
以下我們會分別用幾種 Mock 來處理 addProdToCar
中的依賴。
jest.mock()
用來完全隔離依賴,因此只要對此做 Mock 處理,在測試時執行 addProdToCar
,就不會真的呼叫 uuid
,而是用 Mock 紀錄是否真的有執行到 uuid
取得新 id,jest.mock()
的用法如下:
import uuid from 'uuid/v1';
jest.mock('uuid/v1');
把 uuid
從 module uuid/v1
取出來後,直接對整個 module 做 mock,只要這麼做 uuid
在執行時便不會真的執行,但我們又能夠確認是否有確實執行依賴,以下先在__tests__ 目錄下建立一個 car.test.js ,並寫下 addProdToCar
相關的測試案例:
import uuid from 'uuid/v1';
import car from '../src/utils/car';
jest.mock('uuid/v1');
describe('addProdToCar', () => {
test('check_execute_uuid', () => {
car.addProdToCar('apple', 3);
expect(uuid).toHaveBeenCalled();
});
});
在測試案例 check_execute_uuid 中,先對要測試 addProdToCar
做執行,試著加入第一項產品,但
uuid
是否有確實執行。因此我們做斷言的對象是 uuid
,並使用 toHaveBeenCalled
斷言是否有執行過,如果有的話就沒問題,沒有的話代表 uuid
在 addProdToCar
中的依賴是有問題的。
測試結果如下:
既然是 PASS 就代表, uuid
確實有被執行到,而除了用 toHaveBeenCalled
做斷言外,也可以直接從被 Mock 的 uuid
中取得執行的狀態:
test('check_execute_uuid', () => {
car.addProdToCar('apple', 3);
// expect(uuid).toHaveBeenCalled();
// 執行次數為 1
expect(uuid.mock.calls.length).toBe(1);
});
因為 addProdToCar
會回傳加入產品後的新購物車內容,因此可以用 console 來確認結果為何:
test('check_execute_uuid', () => {
const newCar = car.addProdToCar('apple', 3);
console.log(newCar);
/* 其餘省略 */
});
得出來的結果會因為 uuid
被 Mock,導致沒有真正的邏輯被執行,id 的值就是 undefined:
但某些情況依賴得給我們一些回傳值,才有辦法繼續正常執行完 function,此時就可以使用 mockReturnValue
來指定 mock
的回傳值,下方的測試中將 uuid.mockReturnValue
放入作用域的 beforeAll
中,讓測試開始的時候賦予 Mock 的 uuid
回傳值為字串 9999
:
beforeAll(() => {
uuid.mockReturnValue('9999');
});
接著再執行一次測試,就能看到 undefined 的部分變成 '9999'
了:
如果要設定每次都回傳不同的值也可以選用 mockReturnValueOnce
,它只會設定下一次回傳的值,但如果將每一個 mockReturnValueOnce
串起來,就會依序回傳設定的值,例如:
uuid
.mockReturnValueOnce(123)
.mockReturnValueOnce(null)
.mockReturnValue('abc');
上方的設定會讓 uuid
依序回傳 123
、null
,第三次後永遠回傳'abc'
。
因此透過 Mock,我們便能隔離開使用的依賴,在測試時不會真正執行依賴,而是依照 mockReturnValue
設定一個合理的回傳值,讓關注點持續在被測試的 function 上面,不用害怕因為其他依賴導致測試錯誤。
這個概念也可以用在隔離資料層,有時候我們會使用 fetch
等請求,從後端取得資料,但是整個後端也是一個很大的邏輯,我們當然也不能因為後端的邏輯導致錯誤可能出錯,當然也不只這樣,請求資料可能還會受到網路因素的影響,
fetch
也需使用 Mock 處理。就以 addProdToCar
內的另一個依賴 getCurrentCar
來說,雖然我們可以使用 mockReturnValue
替 getCurrentCar
設定合理的回傳值,讓 addProdToCar
在測試時不會因為依賴出錯,但是畢竟 getCurrentCar
是我們所寫的邏輯,我們就有可能會在今後的修改上動到它,而問題就來了,當你修改到 getCurrentCar
,例如資料結構改變,需從原本回傳陣列改成物件,例如:
const car = {
getCurrentCar: () => car.carContent,
// 改成下方
// getCurrentCar: () => ({ car: car.carContent }),
}
小提醒:
這裡只是舉例,別真的修改哦!
getCurrentCar
的測試案例,並將所有的 mockReturnValue
都改成新的回傳物件,如果不改呢?這裡考大家,如果不改的話,那測試會出錯嗎?
getCurrentCar
和真實的程式碼切開,並利用 mockReturnValue
給它一個陣列 []
,那不管 getCurrentCar
的程式碼如何改,測試時 getCurrentCar
都會回傳 []
,而不會受真正的 getCurrentCar
影響。因此如果在寫測試案例的時候,一律使用 jest.mock
隔離掉邏輯,那當依賴的邏輯或回傳值改變時,不是會增加維護測試案例的成本,就是讓測試無效,導致對測試結果沒有信心,也不會再想去碰它。
為了解決上方的問題,使用 Spy 就是很重要的技巧,它也是製作 Mock,因此可以確認依賴執行的狀況,與 Mock 不同的是 Spy 會真的去執行邏輯,也就是說 Spy 的本體如果改變,又會導致受測 function 在執行時出錯,那在單元測試時也就不會 PASS。
Spy 的用法也很簡單,只需要指定 module 和要 SPy 的 Method 名稱給 jest.spyOn()
,Jest 就會回傳一個 Spy,例如 module 是 car
,要 Spy 的 Method 是 getCurrentCar
就像這樣子處理,我們就能藉由它回傳的 getCurrentCarSpy
驗證執行狀態:
const getCurrentCarSpy = jest.spyOn(
car, 'getCurrentCar',
);
而確認增加商品的案例測試就如下,首先一樣先執行 car.addProdToCar
新增一項商品,然後第一個斷言確認 Spy 是否有被執行,第二個斷言則是用 toEqual
確認新增完商品後的樣子是否和預期的一樣,name
和 count
應該沒有問題,id
的部分則是我們一開始替 uuid
設定了 mockReturnValue
為 '9999'
。
test('check_add_prod', () => {
const newCar = car.addProdToCar('apple', 3);
expect(getCurrentCarSpy).toHaveBeenCalled();
expect(newCar).toEqual(
[{ id: '9999', name: 'apple', count: 3 }],
);
});
測試結果:
如此一來,如果我到 src/util/car.js,中更改 getCurrentCar
:
const car = {
// getCurrentCar: () => car.carContent,
// 改成下方
getCurrentCar: () => ({ car: car.carContent }),
}
再重新執行測試就會錯誤,而不是替 getCurrentCar
用 mockReturnValue
回傳了一個合理值自爽過測試:
注意:
記得要把getCurrentCar
改回來。
文章中用了兩個測試案例,來驗證 addProdToCar
的執行狀況,但是單元測試的最小單位應該是「單一行為」,以下將文中的兩個測試案例合併成一個,因此最後的測試案例會長這樣子:
import uuid from 'uuid/v1';
import car from '../src/utils/car';
jest.mock('uuid/v1');
const getCurrentCarSpy = jest.spyOn(
car, 'getCurrentCar',
);
describe('Car', () => {
beforeAll(() => {
uuid.mockReturnValue('9999');
});
test('check_add_prod', () => {
const newCar = car.addProdToCar('apple', 3);
expect(uuid).toHaveBeenCalled();
expect(getCurrentCarSpy).toHaveBeenCalled();
expect(newCar).toEqual(
[{ id: '9999', name: 'apple', count: 3 }],
);
});
});
測試結果:
本文的範例程式碼會提供在 GitHub 上,歡迎各位參考:)
PS:
哦對!因為我嫌礙眼,所以就把昨天的 index.test.js 砍掉了。
當初在釐清 Mock 的時候真的花了很多時間,而且對於 Mock 的種類也不太清楚,主要是因為身邊有可以一起討論的夥伴,成長起來就比較輕鬆,也不會走太多冤望路,希望我也可以成為你們的夥伴之一:)
如果文章中有任何問題,或是不理解的地方,都可以留言告訴我!謝謝大家!
感謝,讓我很快時間搞懂 jest.mock()
和 jest.spyOn()
的使用時機點!
這個我也和朋友互相討論很久,很開心幫助到妳,有任何想法也可以提出來討論!
最近在看神Q大的系列文章,寫得很讚!
但是想要補充一下~ 新的UUID version4的寫法變成如下:
import { v4 as uuidv4 } from 'uuid';
uuidv4();
看文章的各位可以參考一下!
另外下面的testing可以試試看這個:
describe('Car', () => {
beforeAll(() => {
uuidv4.mockImplementationOnce(() => ('9999'));
});
test('check_add_prod', () => {
const newCar = car.addProdToCar('apple', 3);
expect(uuidv4).toHaveBeenCalled();
expect(getCurrentSpy).toHaveBeenCalled();
expect(newCar).toEqual(
[{ id: '9999', name: 'apple', count: 3 }],
);
console.log(newCar);
});
});